From 0af094770c4ebabc56ff761a8bd215bc397c0f7e Mon Sep 17 00:00:00 2001 From: João Augusto Costa Branco Marado Torres Date: Tue, 5 Aug 2025 18:50:37 +0100 Subject: refactor: reading page review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: João Augusto Costa Branco Marado Torres --- src/pages/blog/read/[slug].astro | 576 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 576 insertions(+) create mode 100644 src/pages/blog/read/[slug].astro (limited to 'src/pages/blog/read/[slug].astro') diff --git a/src/pages/blog/read/[slug].astro b/src/pages/blog/read/[slug].astro new file mode 100644 index 0000000..263b31d --- /dev/null +++ b/src/pages/blog/read/[slug].astro @@ -0,0 +1,576 @@ +--- +import { type CollectionEntry, getCollection } from "astro:content"; +import { render } from "astro:content"; +import Translations from "@components/Translations.astro"; +import KeywordsList from "@components/organisms/KeywordsList.astro"; +import Citations from "@components/Citations.astro"; +import Signature from "@components/templates/signature/Signature.astro"; +import CopyrightNotice from "@components/templates/CopyrightNotice.astro"; +import { verifier as verifierPrototype } from "@lib/pgp/verify"; +import { + fromPosts, + getSigners, + getSignersIDs, + isTranslation, + licenseNotice, + licenseURL, +} from "@lib/collection/helpers"; +import { defined, get, transform } from "@utils/anonymous"; +import Authors from "@components/templates/Authors.astro"; +import Base from "@layouts/Base.astro"; +import type { + GetStaticPaths, + InferGetStaticParamsType, + InferGetStaticPropsType, +} from "astro"; +import DateTime from "@components/organisms/Date.astro"; +import { getUserIDsFromKey } from "@lib/pgp/user"; +import type { PublicKey, UserIDPacket } from "openpgp"; +import type { BlogPosting, Person } from "@lib/collection/types"; +import { + type MicroEntry, + Original, + type OriginalEntry, + Translation, +} from "@lib/collection/schemas"; +import { getEntry } from "astro:content"; +import { getEntries } from "astro:content"; +import readingTime from "reading-time"; +import { fileCreationCommitDate } from "@lib/git/log"; + +export const getStaticPaths = (async (): Promise< + { + params: { slug: string }; + props: CollectionEntry<"blog">; + }[] +> => { + const posts = await getCollection("blog"); + return posts.map((post) => ({ + params: { slug: post.id }, + props: post, + })); +}) satisfies GetStaticPaths; + +type Params = InferGetStaticParamsType; +type Props = InferGetStaticPropsType; + +let post: Props | undefined = Astro.props; + +const verifier = await verifierPrototype.then((x) => x.clone()); + +const signers: Map< + string, + { + signer: Awaited>[number]; + users: UserIDPacket[]; + key: PublicKey; + } +> = new Map(); +// Add signers public keys to keyring +for (const signer of await getSigners(post)) { + const { data } = signer.entity; + const key = await verifier.addKeyFromArmor(data.publickey.armor); + signers.set(key.getFingerprint(), { + signer, + users: getUserIDsFromKey(undefined, key), + key, + }); +} + +const createPerson = ( + { signer, users }: typeof signers extends Map ? V : never, +): Person | undefined => ({ + "@type": "Person", + "@id": signer.entity.id, // TODO: URL + name: users.find(({ name }) => name.length > 0)?.name, + url: signer.entity.data.websites, + email: users.find(({ email }) => email.length > 0)?.email, +}); + +const signersValues = Array.from(signers.values()); +const author: Person | undefined = transform( + signersValues.find(({ signer }) => signer.role === "author"), + (x) => x !== undefined ? createPerson(x) : undefined, +); +const coauthors: Person[] = signersValues.filter(({ signer }) => + signer.role === "co-author" +).map(createPerson).filter(defined); +const translators: Person[] = signersValues.filter(({ signer }) => + signer.role === "translator" +).map(createPerson).filter(defined); + +const { id, data, rendered, body, filePath } = post; + +const path = new URL(`file://${Deno.cwd()}/${filePath}`); +const verification = post.filePath !== undefined + ? await verifier.verify([path]) + : undefined; + +const commit = await verification?.commit; + +const { title, lang, dateCreated, dateUpdated, license } = data; + +let original: OriginalEntry | MicroEntry; +try { + const { translationOf } = Translation.parse(post); + const maybeOriginal = await getEntry(translationOf) as + | OriginalEntry + | MicroEntry + | undefined; + + if (maybeOriginal === undefined) { + throw new Error(`Original post not found for ${id}`); + } + + original = maybeOriginal; + + const { author: [originalAuthors], "co-author": originalCoauthors } = + getSignersIDs(original); + const originalAuthor = originalAuthors?.[0]; + + if ( + (author !== undefined && + author["@id"] !== originalAuthor) || + !new Set(coauthors).isSubsetOf(new Set(originalCoauthors)) + ) { + throw new Error( + `Post ${id} has mismatched (co-)authors from original post ${original.id}`, + ); + } + + for (const { "@id": t } of translators) { + if ( + originalAuthor === t || originalCoauthors.includes(t) + ) { + throw new Error( + `Translator ${t} in ${id} is already a (co-)author in original post`, + ); + } + } +} catch { + original = post as OriginalEntry | MicroEntry; + if (signersValues.some(({ signer }) => signer.role === "translator")) { + throw new Error( + `Post ${id} is not a translation but has translators defined`, + ); + } +} + +const translationsSet = await fromPosts( + isTranslation, + (x) => + new Set( + x.filter(({ data }) => data.translationOf.id === original.id).map( + get("id"), + ), + ), +); +translationsSet.add(original.id); + +const translations = await getEntries( + Array.from(translationsSet).map((id) => ({ + collection: original.collection, + id, + })), +); + +const reading = body ? readingTime(body, {}) : undefined; +const minutes = reading === undefined + ? undefined + : Math.ceil(reading.minutes); +const estimative = minutes === undefined + ? undefined + : new Intl.DurationFormat(lang, { + style: "long", + }).format({ hours: Math.floor(minutes / 60), minutes: minutes % 60 }); +const duration = minutes === undefined + ? undefined + : `PT${Math.floor(minutes / 60) > 0 ? Math.floor(minutes / 60) + "H" : ""}${ + minutes % 60 > 0 ? minutes % 60 + "M" : "" + }`; + +const linkedData: BlogPosting & { "@context": "https://schema.org" } = { + "@context": "https://schema.org", + "@type": "BlogPosting", + "@id": Astro.url.href, + url: Astro.url.href, + headline: title, + name: title, + abstract: "description" in data ? data.description : undefined, + alternativeHeadline: "subtitle" in data ? data.subtitle : undefined, + inLanguage: lang, + workTranslations: translations.filter((post) => + post.id !== id && post.id !== original.id + ).map(({ id, data }) => + ({ + "@type": "BlogPosting", + "@id": new URL(`blog/read/${id}`, Astro.site).href, + url: new URL(`blog/read/${id}`, Astro.site).href, + headline: data.title, + name: data.title, + inLanguage: data.lang, + dateCreated: data.dateCreated.toISOString(), + license: licenseURL(data.license)?.href, + translator: data.signers.filter(({ role }) => role === "translator") + .map(( + { entity }, + ): Person => ({ + "@type": "Person", + "@id": entity.id, + })), + }) as BlogPosting + ), + translationOfWork: original.id !== post.id + ? { + "@type": "BlogPosting", + "@id": new URL(`blog/read/${original.id}`, Astro.site).href, + url: new URL(`blog/read/${original.id}`, Astro.site).href, + headline: original.data.title, + name: original.data.title, + inLanguage: original.data.lang as string, + dateCreated: original.data.dateCreated.toISOString(), + license: licenseURL(original.data.license)?.href, + } as BlogPosting + : undefined, + // TODO: version + author, + contributor: coauthors, + translator: translators, + dateCreated: dateCreated.toISOString(), + dateModified: dateUpdated?.toISOString(), + datePublished: await fileCreationCommitDate(path).then((date) => + date?.toISOString() + ), + timeRequired: duration, + wordCount: reading?.words, + articleBody: rendered?.html ?? body, + text: rendered?.html ?? body, + keywords: original.data.keywords, + citation: await transform( + Original.safeParse(original.data).data, + async (o) => { + if (o === undefined) return o; + const related = await getEntries(o.relatedPosts); + return related.map(({ data }): BlogPosting => ({ + "@type": "BlogPosting", + "@id": new URL(`blog/read/${id}`, Astro.site).href, + url: new URL(`blog/read/${id}`, Astro.site).href, + headline: data.title, + name: data.title, + inLanguage: data.lang, + dateCreated: data.dateCreated.toISOString(), + license: licenseURL(data.license)?.href ?? undefined, + })); + }, + ), // TODO: citation V.S. mentions + mentions: await transform( + Original.safeParse(original.data).data, + async (o) => { + if (o === undefined) return o; + const related = await getEntries(o.relatedPosts); + return related.map(({ data }): BlogPosting => ({ + "@type": "BlogPosting", + "@id": new URL(`blog/read/${id}`, Astro.site).href, + url: new URL(`blog/read/${id}`, Astro.site).href, + headline: data.title, + name: data.title, + inLanguage: data.lang, + dateCreated: data.dateCreated.toISOString(), + license: licenseURL(data.license)?.href ?? undefined, + })); + }, + ), // TODO: citation V.S. mentions + copyrightHolder: [author, ...coauthors, ...translators].filter(defined), + copyrightNotice: licenseNotice(license, { + title, + holders: signersValues.map(({ users }) => { + const user = users?.[0]; + if (user === undefined) return undefined; + + const { name, email } = user; + + return (name.length > 0 && email.length > 0) + ? { name, email } + : undefined; + }).filter(defined), + years: new Array( // TODO: get years where there were commits + (dateUpdated?.getFullYear() ?? dateCreated.getFullYear()) - + dateCreated.getFullYear() + 1, + ).fill(dateCreated.getFullYear()).map((x, i) => x + i), + }, lang), + copyrightYear: dateCreated.getFullYear(), + creativeWorkStatus: "Published", + encodingFormat: "text/html", + isAccessibleForFree: true, + license: licenseURL(license)?.href ?? undefined, + publisher: transform(commit?.committer, (commiter) => { + if (commiter === undefined) return undefined; + + const { name, email } = commiter; + + return { + "@type": "Person", + name, + email, + }; + }), +}; + +const { Content } = await render(post); + +post = undefined; +--- + + +
+
+ +
+

{linkedData.headline}

+ { + linkedData.alternativeHeadline && ( +

+ {linkedData.alternativeHeadline} +

+ ) + } +
+ { + linkedData.abstract && + ( +
+

Resumo

+ { + linkedData.abstract.split(new RegExp("\\s{2,}")) + .map(( + x, + ) =>

{x}

) + } +
+ ) + } + {verification && } +
+ { + verification?.verifications && + ( + + ) + } +
+
Data de criação
+
+ +
+ { + linkedData.dateModified && ( +
Última atualização
+
+ +
+ ) + } + { + linkedData.locationCreated && ( +
+
Local de criação
+
{linkedData.locationCreated.name}
+
+ ) + } + { + linkedData.wordCount && linkedData.timeRequired && + ( + <> +
Tempo de leitura estimado
+
+ ~ {estimative} + (palavras: {linkedData.wordCount}) +
+ + ) + } +
+
+
+ { + linkedData.keywords !== undefined && + linkedData.keywords.length > 0 && ( +
+ +
+ ) + } + { + linkedData.citation !== undefined && ( + + ) + } + x + i)} + {license} + /> +
+
+ + + + + + + -- cgit v1.2.3